Conversation
This commit adds comprehensive wallet isolation to ensure orders from different wallets are properly separated and don't interfere with each other. Changes: - Added User field to OrderInfo and WsBasicOrder structs to track wallet ownership - Implemented signature recovery to extract wallet address from ECDSA signatures - Updated State.CreateOrder to store wallet address with each order - Modified WebSocket broadcast filtering to only send order updates to the owning wallet - Added wallet verification in handleOrderStatus to prevent cross-wallet order queries - Preserved wallet information through order modifications The implementation uses Ethereum signature recovery (ECDSA) to derive the wallet address from the request signature, matching the real Hyperliquid API behavior. This fixes the issue where orders from different wallets were being broadcast to all subscribers and enables proper multi-wallet testing scenarios.
When signature recovery fails or isn't implemented, orders are created without a User field. This commit ensures such orders remain queryable by any client, maintaining backward compatibility with existing tests. Wallet isolation is still enforced when both the order and query have wallet addresses set, which will work once proper signature recovery is implemented. This fixes TestQueryOrderStatusWithGoHyperliquid while preserving the wallet isolation functionality for tests that properly set wallets.
Added clearer logging to distinguish between: - Successful signature recovery (wallet isolation enabled) - Failed signature recovery (backward compatibility mode) This helps diagnose wallet isolation issues and makes it clear when orders are not wallet-isolated due to signature recovery failures.
This commit fixes two critical issues:
1. **Proper Signature Recovery (handlers.go)**
- Replaced simplified JSON hashing with proper msgpack-based signing
- Now matches go-hyperliquid's SignL1Action format exactly:
* Msgpack encode action with sorted keys
* Append nonce (8 bytes, big endian)
* Append vault address (20 bytes, zeros for non-vault)
* Append expires timestamp (8 bytes, zeros if not set)
* Keccak hash and recover public key
- This enables proper wallet isolation for multi-wallet scenarios
2. **CLOID Format Validation (integration_test.go)**
- Updated all test CLOIDs to be exactly 32 hex characters
- go-hyperliquid v0.21.0 enforces strict CLOID validation
- Changed from human-readable strings to proper hex format:
* "test-order-1" → "00000000000000000000000000000001"
* "test-modify-order" → "00000000000000000000000000000002"
* etc.
The signature recovery now properly extracts wallet addresses from
ECDSA signatures, enabling true multi-wallet order isolation while
maintaining backward compatibility (orders without valid signatures
remain accessible to all clients).
Fixes TestQueryOrderStatusWithGoHyperliquid and other integration tests.
test: generate private key with package instead of from a fixed string
Summary - server/signature_recovery.go (lines 1-329) moves wallet recovery into a dedicated implementation that reuses go-hyperliquid’s action structs, msgpack ordering, vault/expiry metadata, and EIP‑712 hashing; also pads odd-length signature limbs to eliminate the intermittent TestRecoverWalletFromSignature failure. - server/handlers.go (lines 1-70) now delegates recovery to the new helper, dropping the fragile sorted-map encoder and direct crypto plumbing. - server/handlers_test.go (lines 16-86) exercises recovery via hyperliquid.SignL1Action, ensuring tests share the exact signing path and fixing the previous flake caused by unpadded signatures. - server/testserver_test.go (lines 3-381) updates fill-order tests to query status with the stored wallet/user pair and emits debug logs (via WithLogger) so order queries stop returning unknown_cloid. - server/types.go (lines 9-40) records optional vaultAddress/expiresAfter fields from exchange payloads so signature hashing sees the same inputs the client signed.
Updated documentation to reflect the new signature recovery and wallet isolation capabilities: README.md: - Updated Features section to highlight signature validation and wallet isolation instead of 'no authentication' - Added comprehensive 'Wallet Isolation' section explaining signature recovery process and multi-wallet testing - Updated all CLOID examples to use proper 32-character hex format - Updated Limitations section to remove 'no signature validation' - Added signature_recovery.go to project structure - Improved code examples with proper CLOID generation WEBSOCKET.md: - Removed 'User tracking simplified' from key differences - Removed 'No authentication required' from key differences - Added 'Implemented Features' section highlighting wallet isolation - Moved 'User-based order filtering' from future enhancements to implemented features - Added testnet EIP-712 domain note These changes ensure documentation accurately reflects the current implementation with proper ECDSA signature recovery and wallet-based order isolation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // broadcastOrderUpdates broadcasts order updates to subscribed clients | ||
| // In the mock server, we broadcast to all orderUpdates subscribers | ||
| // Orders are filtered by wallet - each client only receives updates for their own orders | ||
| func (wsm *WebSocketManager) broadcastOrderUpdates() { | ||
| for update := range wsm.orderUpdatesCh { | ||
| wsm.mu.RLock() | ||
| for conn, state := range wsm.connections { | ||
| state.mu.RLock() | ||
| // In the mock, broadcast to anyone subscribed to orderUpdates | ||
| if state.orderUpdatesUser != "" { | ||
| state.mu.RUnlock() | ||
| msg := map[string]interface{}{ | ||
| "channel": "orderUpdates", | ||
| "data": update.Orders, | ||
| subscribedUser := state.orderUpdatesUser | ||
| state.mu.RUnlock() | ||
|
|
||
| // Only send updates if: | ||
| // 1. Client is subscribed to orderUpdates (subscribedUser != "") | ||
| // 2. The order belongs to the subscribed user | ||
| if subscribedUser != "" && len(update.Orders) > 0 { | ||
| orderUser := update.Orders[0].Order.User | ||
| if orderUser == subscribedUser { |
There was a problem hiding this comment.
Preserve orderUpdates for unsigned orders
Subscriptions register a non‑empty orderUpdatesUser, but broadcastOrderUpdates now emits an update only when the first order’s User matches that string. Orders created when signature recovery fails are intentionally stored with an empty user (the comment in HandleExchange says they should remain non‑isolated), yet this loop never forwards those updates to anyone because orderUser is "" and no subscriber ever has an empty user. Before this change unsigned orders were broadcast to all listeners, so clients that don’t provide a recoverable signature will silently stop receiving order updates. Consider skipping the user check when orderUser is empty or broadcasting those orders to all subscribers to keep the previous behaviour.
Useful? React with 👍 / 👎.
| switch { | ||
| case req.Oid != nil && req.Oid.Raw() == "" && req.Oid.Valid(): | ||
| order, exists = h.state.GetOrderByOid(req.Oid.Int64()) | ||
| } else if req.Cloid != nil && *req.Cloid != "" { | ||
| // Query by CLOID | ||
| order, exists = h.state.GetOrder(*req.Cloid) | ||
| } else if req.User != "" { | ||
| // In a real implementation, we'd filter by user | ||
| // For the mock, we just return unknown | ||
| case req.Oid != nil && req.Oid.Raw() != "": | ||
| order, exists = h.state.GetOrder(req.Oid.Raw()) |
There was a problem hiding this comment.
Treat string‑encoded OIDs as valid order IDs
handleOrderStatus now distinguishes numeric versus string oid parameters by checking FlexibleOid.Raw(). When a client sends an OID as a string (e.g. "oid":"123" or "oid":"0x7b"), FlexibleOid sets Valid() to true but also keeps Raw() non‑empty, so the new switch goes down the GetOrder path and looks up a CLOID instead. That returns unknown_cloid even though the OID was valid and previously would have matched through GetOrderByOid. Since many JSON clients encode large integers as strings, this change breaks legitimate order‑status queries. Invoke GetOrderByOid whenever req.Oid.Valid() is true, regardless of Raw(), and reserve the CLOID lookup for cases where the OID was invalid but a raw string is present.
Useful? React with 👍 / 👎.
…tion When signature recovery fails, orders are created with an empty User field to maintain backward compatibility. However, the WebSocket broadcast logic was filtering these out completely, causing clients to never receive updates for unsigned orders. Changes: - Orders with empty User field (signature recovery failed) are now broadcast to ALL orderUpdates subscribers (backward compatibility) - Orders with non-empty User field are broadcast only to matching subscriber (proper wallet isolation) - Improved code clarity with explicit shouldSend logic and comments This ensures that: 1. Tests without valid signatures still receive order updates 2. Multi-wallet scenarios properly isolate orders by wallet 3. Backward compatibility is preserved for existing code Fixes issue identified by code review where unsigned orders would silently stop receiving WebSocket updates.
This commit adds comprehensive wallet isolation to ensure orders from different wallets are properly separated and don't interfere with each other.
Changes:
The implementation uses Ethereum signature recovery (ECDSA) to derive the wallet address from the request signature, matching the real Hyperliquid API behavior.
This fixes the issue where orders from different wallets were being broadcast to all subscribers and enables proper multi-wallet testing scenarios.